Skip to content

serviceability-instruction/sdk-rs: carry the IP ownership proof and Ed25519 instruction - #4224

Merged
elitegreg merged 2 commits into
mainfrom
gm/ip-proof-instruction-sdk
Aug 25, 2026
Merged

serviceability-instruction/sdk-rs: carry the IP ownership proof and Ed25519 instruction#4224
elitegreg merged 2 commits into
mainfrom
gm/ip-proof-instruction-sdk

Conversation

@elitegreg

@elitegreg elitegreg commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Resolves #4200. Part of RFC-27 (rfcs/rfc27-ip-verification.md); tracker #4194.

Summary of Changes

  • The serviceability program validates an optional IpOwnershipProof as of serviceability: validate IpOwnershipProof via the Ed25519 precompile #4211, but nothing client-side could build a transaction carrying one. This adds the two missing pieces: the native Ed25519SigVerify instruction the program introspects the Instructions sysvar to find, and the Rust SDK plumbing to send it alongside the creation it authorizes.
  • New ip_proof module in doublezero-serviceability-instruction: ed25519_verification_instruction lays out the precompile instruction for a proof, and with_ed25519_verification pairs it ahead of the create instruction. The offset layout comes from solana_ed25519_program rather than being written out here — the program rejects any instruction whose offsets name a different instruction or run past the end of its data, so a hand-rolled header is a silent way to build a transaction that can never land.
  • The builders keep their signatures. Issue serviceability-instruction/sdk-rs: carry the IP ownership proof and Ed25519 instruction #4200 asks them to take Option<IpOwnershipProof>, but serviceability: validate IpOwnershipProof via the Ed25519 precompile #4211 already put ip_proof in UserCreateArgs / UserCreateSubscribeArgs and made both builders derive the Instructions sysvar append from it. Adding a positional parameter would give the proof two homes that can disagree, so it stays in the args.
  • DoubleZeroClient gains send_instructions for a transaction needing more than one instruction. send_transaction is unchanged, so its 223 call sites are untouched.
  • CreateUserCommand and CreateSubscribeUserCommand take an optional ip_proof. A shared helper resolves the verifier key from GlobalState.ip_verifier_authority_pk — the same place the program reads it — so a caller cannot pair a proof with the wrong key, and refuses a proof naming a different owner, address, or user type before the transaction is paid for. The epoch window is deliberately left to the program: the ledger's current epoch is its to judge.
  • On the --owner override path the proof must name that owner, not the payer. create_user_core binds the proof to the user's effective owner, which differs from the payer on the foundation-allowlist path.
  • Passing None produces the pre-RFC-27 transaction byte for byte. Nothing sets ip_proof yet; the CLI obtaining a proof during connect is cli: obtain an IP ownership proof during connect and attach it to user creation #4201.

Transaction size

The issue asks for the headroom at realistic dz_prefix_count, and two tests pin it rather than leaving it to a comment. With a proof attached, CreateUser fits 10 dz_prefix_block accounts and CreateSubscribeUser 8, against 21 and 19 without one — the proof costs about eleven slots: the 111-byte Option<IpOwnershipProof> in the args, a 169-byte Ed25519 instruction, and two more account keys (the Instructions sysvar and the Ed25519 program). Devices carry one or two prefixes, so the margin is large either way, but a future field cannot quietly eat the rest of it without failing these tests.

Diff Breakdown

Category Files Lines (+/-) Net
Core logic 6 +708 / -16 +692
Tests 3 +411 / -6 +405
Fixtures 1 +14 / -2 +12
Scaffolding 4 +13 / -0 +13
Config/build 2 +10 / -3 +7
Docs 1 +4 / -0 +4
Generated 1 +2 / -0 +2
Total 18 +1162 / -27 +1135

The core-logic files carry 507 lines of inline #[cfg(test)] tests of their own, leaving about 200 lines of new logic — two builders, one shared SDK helper, and one trait method.

Key files (click to expand)

Testing Verification

  • test_builder_user_creation_with_ip_proof runs both builders against the in-process program with require-ip-ownership-proof set and a real verifier key in global state, so each creation only lands if the builder-assembled Ed25519 instruction actually validates — the acceptance criterion. Both users end Activated, and the multicast one carries its subscription. This is the first test in the repo that submits a precompile instruction produced by production code rather than a test helper.
  • The pre-existing test_builder_create_subscribe_user is the None case end to end, and builder-level tests assert that a None proof leaves both account lists byte-identical to the pre-RFC-27 layout.
  • Ed25519 layout is asserted field by field against the offsets the program's own parser reads: one signature, all three instruction indices at the u16::MAX sentinel, and the key / signature / message slices at the declared offsets. A separate test bends the proof's epoch and asserts the covered message moves with it, so the builder can never sign for a message the program will not reconstruct.
  • SDK command tests cover the proof path on both commands and each local rejection: unset verifier key, a proof naming another payer, a proof for another address, a proof for another user type, and — on the --owner path — a proof bound to the payer instead of the owner.
  • Transaction-size tests assert the exact maximum dz_prefix_count with and without a proof, for both instructions.
  • user_ip_proof_test (34), doublezero-daemon-cli (180), and doublezero-serviceability-cli (419) all still pass.
  • make generate-fixtures produces no .bin / .json change, confirming the no-proof wire shape is untouched.

@elitegreg
elitegreg marked this pull request as ready for review August 21, 2026 19:26
@elitegreg
elitegreg requested a review from a team August 21, 2026 19:26

@juan-malbeclabs juan-malbeclabs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the RFC-27 client side. The mechanism is correct: I re-derived the Ed25519 precompile layout against solana-ed25519-program-3.0.0 (DATA_START=16, key→sig→msg order, the u16::MAX sentinel) and it agrees with check_ed25519_instruction's reconstruction; the sysvar scan is position-independent and bounds-checked; the epoch window reads correctly at epoch 0; and proof_owner lines up with create_user_core's effective_owner on both paths (CreateUser passes owner_override: None, so the payer is right; CreateSubscribeUser derives it from args.owner, so accesspass_payer is right). No wrong conditions, off-by-ones, dropped errors, or broken callers.

Two notes, both on the local pre-send gate this PR introduces, inline.

Unrelated to the diff but newly relevant: client.rs:244 maps InstructionError::Custom(n) to DoubleZeroError::from(n) and discards the instruction index. That was safe while every instruction was serviceability's — now that transactions carry a precompile instruction whose PrecompileError lives in the same Custom space, InvalidSignature (2) would print as InvalidExchangePubkey. Unreachable while skip_preflight is hardcoded true, so a comment or a cheap index guard is enough.

Verified locally: cargo test -p doublezero-serviceability-instruction (81 pass), cargo test -p doublezero_sdk (189 pass, including all 7 new RFC-27 command tests), rfc26_builders_test test_builder_user_creation_with_ip_proof, and cargo check --workspace --all-targets (only the pre-existing, unrelated doublezero-geolocation::entrypoint test-target failures).

Comment thread smartcontract/sdk/rs/src/commands/user/mod.rs
Comment thread smartcontract/sdk/rs/src/commands/user/mod.rs
@elitegreg
elitegreg force-pushed the gm/ip-proof-instruction-sdk branch from f83a28f to f0226ab Compare August 24, 2026 19:16

@juan-malbeclabs juan-malbeclabs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at f0226ab4. Both inline notes and the Custom(n) point from my review body are resolved, and I re-checked the rebase delta on top of #4120.

Signature check (sdk/rs/src/commands/user/mod.rs:84) — verify(proof, &verifier) now runs after the GlobalState lookup and before the Ed25519 instruction is built, so a proof signed by a rotated key fails locally with a named error instead of the invisible confirmation timeout. The signer feature is enabled on the sdk/rs dependency only; the crate's default build stays borsh + solana-program, so the BPF graph is unaffected. Both proof_for helpers now sign for real, which means the happy-path tests exercise a signature that actually verifies rather than [5u8; 64], and test_commands_user_create_with_ip_proof_rejects_a_rotated_verifier_key covers the rotation case directly.

Version check (mod.rs:49) — is_supported_version runs first, matching the program's order in ip_proof.rs:125, and the epoch window is still correctly left to the program with the doc comment explaining why.

Custom(n) mapping (client.rs:250) — the guard on transaction.message.program_id(index) is the better fix over the comment I suggested. It reads the legacy message the send path actually builds, an out-of-range index falls through to the raw error, and it is strictly more correct for any non-serviceability instruction, not just the precompile.

Transaction-size guard (create_subscribe.rs:154-180) — assembling the instruction list before the measurement is right. The Ed25519 instruction carries ~170 bytes of data plus the sysvar and precompile account keys, which the old [cu_limit, heap_frame, ix] message missed entirely; the group cap is now judged against what goes on the wire. The accesspass_owner parameter on expect_create_lookups is a faithful adaptation — the --owner test seeds the pass under the owner and signs the proof for the owner, which is what create_user_core's effective_owner resolves to on that path.

ip_proof.rs in the instruction crate is byte-identical to what I reviewed pre-rebase; the only other delta is the test-only &[] for the new extra_mgroup_pks parameter.

Two non-blocking notes, neither worth another round:

  • match instructions.len() { 1 => ..., _ => ... } in create_subscribe.rs would read more directly as a match on self.ip_proof, since that is what actually decides the shape.
  • Every caller still passes ip_proof: None, so RFC-27 is not reachable end-to-end yet. Expected — the issuing side lands separately — just flagging that nothing exercises this outside tests today.

Approving.

@elitegreg
elitegreg enabled auto-merge (squash) August 25, 2026 02:03
…d25519 instruction

Resolves #4200. Part of RFC-27; tracker #4194.

The program validates an optional IpOwnershipProof as of #4211, but nothing
client-side could produce a transaction carrying one. This adds the two pieces a
caller needs: the native Ed25519SigVerify instruction the program introspects the
Instructions sysvar to find, and the Rust SDK plumbing to send it alongside the
creation it authorizes.

- crates/doublezero-serviceability-instruction gains an ip_proof module:
  ed25519_verification_instruction lays out the precompile instruction for a
  proof, and with_ed25519_verification pairs it ahead of the create instruction.
  The offset layout comes from solana_ed25519_program rather than being written
  out, because the program rejects any instruction whose offsets name another
  instruction or run past the end of its data.
- The builders keep their signatures: #4211 already put ip_proof in the args and
  made them append the Instructions sysvar from it, so the proof travels in one
  place rather than two that can disagree.
- DoubleZeroClient gains send_instructions for a transaction that needs more than
  one instruction. send_transaction is unchanged.
- CreateUserCommand and CreateSubscribeUserCommand take an optional ip_proof.
  A shared helper resolves the verifier key from
  GlobalState.ip_verifier_authority_pk, the same place the program reads it, so a
  caller cannot pair a proof with the wrong key, and refuses a proof naming a
  different owner, address, or user type before the transaction is paid for. On
  the owner-override path the proof must name that owner: the program binds it to
  the user's effective owner, not the payer. Omitting the proof produces the
  pre-RFC-27 transaction unchanged.
- Nothing sets ip_proof yet; the CLI is #4201.

Transaction headroom, pinned by tests: with a proof attached CreateUser fits 10
dz_prefix_block accounts and CreateSubscribeUser 8, against 21 and 19 without
one. The proof costs about eleven slots — the 111-byte Option<IpOwnershipProof>
in the args, a 169-byte Ed25519 instruction, and two more account keys. Devices
carry one or two prefixes.
The local pre-send gate mirrored the program's payer, client_ip and
user_type comparisons but not its version check, and never checked the
proof's signature against the verifier key it reads from GlobalState.

A proof signed by a rotated verifier key passed every check, and the
Ed25519 instruction was then built with the current key over the stale
signature. The precompile rejects that in the leader, and because the
send path uses skip_preflight the transaction never lands: no
TransactionError, no logs, just a confirmation timeout. That was the one
failure in this path with no named error, which is what the local gate
exists to prevent. An unsupported version was cheaper but still paid for:
the transaction lands and the program returns IpProofVersionUnsupported.

Also stop mapping every InstructionError::Custom through DoubleZeroError.
Custom numbers belong to whichever program raised them, and these
transactions now carry a precompile instruction whose PrecompileError
shares that space, so the mapping is guarded by the failing instruction's
program id.

The SDK enables doublezero-ip-proof's signer feature for the
verification half only; it still never issues a proof.
@elitegreg
elitegreg force-pushed the gm/ip-proof-instruction-sdk branch from f0226ab to b57e469 Compare August 25, 2026 02:06
@elitegreg
elitegreg merged commit 1ca145c into main Aug 25, 2026
47 of 49 checks passed
@elitegreg
elitegreg deleted the gm/ip-proof-instruction-sdk branch August 25, 2026 02:31
elitegreg added a commit that referenced this pull request Aug 25, 2026
Resolves #4201. Part of RFC-27; tracker #4194. Stacked on #4224.

The SDK can carry an RFC-27 proof as of #4200, but nothing obtained one. This
makes `doublezero connect` ask the verification service for a proof and attach it
to user creation.

- config: NetworkConfig gains ip_verifier_url, overridable by DZ_IP_VERIFIER_URL
  and by a new --ip-verifier-url on connect. Only localnet has a built-in
  default, the verifier's own listen address; deployed URLs land with #4199, and
  until then those environments simply have no verifier.
- New ip_proof module in doublezero-daemon-cli: an automock'd IpProofClient trait
  plus a blocking reqwest implementation. The request is bound to the address the
  tunnel will use, so a multi-homed host proves the address it actually
  originates from; on a NATed host that bind fails and the request falls back to
  the default egress, where the service observes the NAT address the daemon
  already discovered.
- The service's observed address is authoritative. Where it disagrees with what
  the daemon discovered, connect stops and names both: attaching the proof would
  guarantee an onchain rejection, and dropping it would bind an address nobody
  proved.
- Every other failure is reported and non-fatal — unconfigured, unreachable, or
  declined (a CGNAT source, a rate limit) each print the specific reason and
  continue without a proof. The program is the enforcement point, so this
  succeeds while require-ip-ownership-proof is clear and fails with a named error
  once it is set, which is the behavior wanted during rollout.
- One proof per invocation: a single connect only ever creates users of one
  user_type, and the proof binds user_type.

The issue asks to fail when an explicit --client-ip disagrees with the service.
That flag is deprecated and ignored on the CLI, so the disagreement that can
actually happen is between the daemon's discovered address and the service's
observed one; that is what is checked.
elitegreg added a commit that referenced this pull request Aug 25, 2026
Resolves #4201. Part of RFC-27; tracker #4194. Stacked on #4224.

The SDK can carry an RFC-27 proof as of #4200, but nothing obtained one. This
makes `doublezero connect` ask the verification service for a proof and attach it
to user creation.

- config: NetworkConfig gains ip_verifier_url, overridable by DZ_IP_VERIFIER_URL
  and by a new --ip-verifier-url on connect. Only localnet has a built-in
  default, the verifier's own listen address; deployed URLs land with #4199, and
  until then those environments simply have no verifier.
- New ip_proof module in doublezero-daemon-cli: an automock'd IpProofClient trait
  plus a blocking reqwest implementation. The request is bound to the address the
  tunnel will use, so a multi-homed host proves the address it actually
  originates from; on a NATed host that bind fails and the request falls back to
  the default egress, where the service observes the NAT address the daemon
  already discovered.
- The service's observed address is authoritative. Where it disagrees with what
  the daemon discovered, connect stops and names both: attaching the proof would
  guarantee an onchain rejection, and dropping it would bind an address nobody
  proved.
- Every other failure is reported and non-fatal — unconfigured, unreachable, or
  declined (a CGNAT source, a rate limit) each print the specific reason and
  continue without a proof. The program is the enforcement point, so this
  succeeds while require-ip-ownership-proof is clear and fails with a named error
  once it is set, which is the behavior wanted during rollout.
- One proof per invocation: a single connect only ever creates users of one
  user_type, and the proof binds user_type.

The issue asks to fail when an explicit --client-ip disagrees with the service.
That flag is deprecated and ignored on the CLI, so the disagreement that can
actually happen is between the daemon's discovered address and the service's
observed one; that is what is checked.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

serviceability-instruction/sdk-rs: carry the IP ownership proof and Ed25519 instruction

2 participants